Skip to content

Support LT-22605: Add ability to limit HermitCrab parses#452

Open
jtmaxwell3 wants to merge 5 commits into
masterfrom
LT-22605
Open

Support LT-22605: Add ability to limit HermitCrab parses#452
jtmaxwell3 wants to merge 5 commits into
masterfrom
LT-22605

Conversation

@jtmaxwell3

@jtmaxwell3 jtmaxwell3 commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

MaxUnapplications was an experimental variable that never got incorporated into FieldWorks. I decided to rename it MaxAlternatives for clarity and to change it to throw a TimeoutException since the MaxUnapplications implementation truncated results without telling the user and since it could produce different results with different parses of the same word because of multi-threading.

MaxAlternatives caps the number of alternatives produced by each stratum during analysis. This is where most of the blowup occurs since rules and templates within a stratum are unordered and since rules and templates are optional during unapplication (analysis). The stratum checks MaxAlternatives as it iterates over the results produced by ApplyTemplates and ApplyMorphological rules. This terminates without a full enumeration because ApplyTemplates and ApplyMorphologicalRules use lazy evaluation (yield return). However, _mrulesRule and _mTemplatesRule do not use lazy evaluation, so we set MaxAlternatives on them as well. Because of this, the maximum number of alternatives can be some multiple of MaxAlternatives in the worst case. We could also pass in the current number of alternatives to _mrulesRule and _mTemplatesRule, but we probably don't need to be that precise. We are just trying to avoid words taking forever to parse.

We could also limit the number of lexical entries found after analysis but the number of lexical entries that are found after analysis is usually proportionate to the number of alternatives, so it probably isn't necessary. Furthermore, we could limit the number of alternatives considered within each stratum during synthesis, but the number of alternatives generated is usually proportionate to the number of lexical entries found because rules are obligatory in the synthesis direction and there are rarely alternative orderings of the rules that match. Limiting the alternatives produced by each stratum should be enough to bound the amount of time spent parsing a word.

NB: I had to wrap the bodies of Parallel.ForEach statements with try ... catch in ParallelCombinationRuleCascade and ParallelPermutationRuleCascade in order to get the TimeoutException to propagate correctly (noted by Devin).

I tested this against Aweti-opap-no-go from Andy Black by setting MaxAlternatives to 1000 and all of the words finished in a reasonable amount of time and the words that were limited gave a reasonable error message.


This change is Reviewable

@jtmaxwell3 jtmaxwell3 marked this pull request as draft July 7, 2026 17:57
@codecov-commenter

codecov-commenter commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 54.34783% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.29%. Comparing base (d9deb16) to head (0befc73).

Files with missing lines Patch % Lines
...IL.Machine/Rules/ParallelPermutationRuleCascade.cs 0.00% 25 Missing ⚠️
...IL.Machine/Rules/ParallelCombinationRuleCascade.cs 70.00% 6 Missing and 3 partials ⚠️
...chine.Morphology.HermitCrab/AnalysisStratumRule.cs 69.23% 4 Missing ⚠️
src/SIL.Machine/Rules/RuleBatch.cs 60.00% 1 Missing and 1 partial ⚠️
src/SIL.Machine/Rules/RuleCascade.cs 71.42% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #452      +/-   ##
==========================================
- Coverage   73.33%   73.29%   -0.04%     
==========================================
  Files         443      443              
  Lines       37214    37273      +59     
  Branches     5110     5114       +4     
==========================================
+ Hits        27290    27319      +29     
- Misses       8801     8829      +28     
- Partials     1123     1125       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@johnml1135

Copy link
Copy Markdown
Collaborator

Hello John,

I am still trying to get a grapple on how this all works (getting better). I like the approach - having a deterministic "kill the parse if it takes too long" and it seems like a meaningful improvement over what we have. I investigated it with AI and here are some comments that seem reasonable to me:

The robustness gaps are mostly small, contained fixes:

1. Unwrap the parallel exception — either wrap the Parallel.ForEach body in ParallelCombinationRuleCascade with the same try/catch-and-rethrow pattern Morpher.Synthesize already uses (Morpher.cs:330-338), or move the check outside the parallel region. Without this, the feature is broken on the default configuration it's meant to serve.
2. Count with an Interlocked counter instead of output.Count() — fixes both the O(n²) counting on ConcurrentStack and the interleaving-dependent throw-or-not behavior near the boundary. One line of state, restores the determinism claim.
3. Push the cap into _prulesRule too — the CheckMaxAlternatives call is already sitting in LinearRuleCascade; it just never gets armed. One extra SetMaxAlternatives call.
4. Fix the mixed condition — use _maxAlternatives consistently instead of _morpher.MaxAlternatives > 0 && count >= _maxAlternatives, which removes the latent instant-throw on the un-initialized path, and align the >= vs > off-by-one while there.
5. A dedicated exception type (e.g. MaxAlternativesExceededException : Exception) rather than TimeoutException would let callers distinguish "grammar too ambiguous for this word" from actual timeouts, and carries the count for the error message. Cheap, but an API decision the author may prefer to keep as-is if FLEx already handles TimeoutException.

The one thing that isn't a small tweak is that it bounds breadth, not depth — a single slow pattern match with few alternatives sails past the cap. Fixing that properly means threading a real cancellation/deadline through the matcher, which is a much bigger change. But that's arguably fine as a scope cut: the observed 30-minute words are alternative-explosion cases, and this handles those. The design smell of mutable SetMaxAlternatives state on shared compiled rules is similar — worth noting in review, not worth blocking over, since every current caller sets a constant value.

So: sound idea, right layer, and items 1–4 are each a few lines. Item 1 is the only one I'd call a must-fix before merge, since it defeats the feature in the default build.

Looking forward to this landing!

@jtmaxwell3

Copy link
Copy Markdown
Collaborator Author

@johnml1135:

1: I don’t understand why "the feature is broken on the default configuration". It worked fine for me.
2: I’m not sure what is meant by “Count with an Interlocked counter”.
3: _prulesRule always has exactly one alternative because of how phonological rules work, so there is nothing to bound.
5: I don’t think we need a dedicated exception type. There are no other timeouts, and even though it isn’t technically a timeout, it effectively is.

I’m fine with only bounding the breadth, not the depth. I’m just trying to prevent an exponential explosion, and I don’t think that you can have an exponential depth.

@jtmaxwell3 jtmaxwell3 marked this pull request as ready for review July 9, 2026 15:31
@jtmaxwell3 jtmaxwell3 requested a review from ddaspit July 9, 2026 15:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants